29 GLB_GLTF 3D模型加载基础

GLB/GLTF 3D模型加载基础

关联:索引

要解决的问题

本讲定位(与上一讲衔接,避免重复)

章节内容(本讲核心)

本讲边界说明

环境与先修(默认沿用上一讲工程)

先修要求:

如你需要补装依赖(仅在未安装时执行):

# 安装 Three.js 运行时依赖
npm i three

# 仅在 TypeScript 报 “找不到声明文件” 时再安装(老工程更常见)
npm i -D @types/three

解释:

可复制运行:最小工程骨架(没有现成工程时使用)

  1. 创建工程
# 在当前目录创建一个新的 Vue3 + TS 工程(名字可自定义)
npm create vite@latest gltf-lab -- --template vue-ts

cd gltf-lab

解释:

  1. 安装依赖
npm i
npm i three

解释:

  1. 放置模型文件
gltf-lab/
└─ public/
   └─ models/
      └─ robot-arm.glb

解释:

src/main.ts(可直接复制):

import { createApp } from 'vue';
import App from './App.vue';
import './styles.css';

// Vue 应用入口:把根组件挂载到 index.html 的 #app 容器
createApp(App).mount('#app');

解释:

src/App.vue(可直接复制:提供单模型/批量/关节预埋三套实验切换):

<template>
  <main class="app">
    <h1>GLB / GLTF 模型加载实验</h1>

    <!-- 顶部 tabs:三套实验在同一工程里切换,便于课堂对照与排错 -->
    <nav class="tabs">
      <button
        v-for="lab in labs"
        :key="lab.key"
        type="button"
        :class="['tab', currentLab === lab.key ? 'tab--active' : '']"
        @click="currentLab = lab.key"
      >
        {{ lab.label }}
      </button>
    </nav>

    <GltfSingleLoadLab v-if="currentLab === 'single'" />
    <GltfBatchLoadLab v-else-if="currentLab === 'batch'" />
    <GltfJointPrepLab v-else />
  </main>
</template>

<script setup lang="ts">
import { ref } from 'vue';
// 三个实验组件:单模型加载 / 批量加载 / 关节联动预埋(滑块模拟)
import GltfBatchLoadLab from './components/GltfBatchLoadLab.vue';
import GltfJointPrepLab from './components/GltfJointPrepLab.vue';
import GltfSingleLoadLab from './components/GltfSingleLoadLab.vue';

type LabKey = 'single' | 'batch' | 'joint';

// tabs 配置:只改这里就能调整显示顺序或文案
const labs: Array<{ key: LabKey; label: string }> = [
  { key: 'single', label: '单模型加载' },
  { key: 'batch', label: '批量加载' },
  { key: 'joint', label: '关节预埋' },
];

// 当前选中的实验页签
const currentLab = ref<LabKey>('single');
</script>

<style scoped>
.app {
  display: grid;
  gap: 12px;
  padding: 12px;
  background: #0b1220;
  color: #e5e7eb;
  min-height: 100vh;
}

.tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.tab {
  border: 1px solid rgba(148, 163, 184, 0.18);
  border-radius: 999px;
  padding: 8px 14px;
  background: rgba(15, 23, 42, 0.84);
  color: #e2e8f0;
  cursor: pointer;
  font-weight: 700;
}

.tab--active {
  border-color: rgba(56, 189, 248, 0.65);
  box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.12);
}
</style>

解释:

  1. 启动开发服务器
npm run dev

解释:

public/
└─ models/
   ├─ robot-arm.glb
   ├─ conveyor.glb
   └─ sorting-line.glb

解释:


在上一讲里,我们已经能用基础几何体搭出工作台、立柱、工件,也能用灯光和阴影让场景看起来更像工业环境。但真实项目很快会遇到一个问题:

工程结论:

从这一讲开始,Three.js 的重点不再只是“手工画出一个物体”,而是“把已有工业模型稳定地加载进来,并让它可控、可测、可排错”。

1) 一句话理解 glTF 家族

2) GLB 与 GLTF 的对比表

对比项 GLB GLTF
文件组织 单文件 通常是 .gltf + .bin + 贴图
传输便利性 更高,拷贝/部署简单 需要保证关联资源路径完整
可读性 二进制,不适合直接阅读 JSON 文本,可查看结构
工业项目建议 优先作为前端交付格式 适合保留原始结构和资源引用关系

如果你只想“先把模型跑起来”,优先选 .glb;如果你要分析资源结构、保留多贴图和外部文件关系,.gltf 更直观,但路径管理更严格。

3) 工业场景里的选型建议

4) 从 world / sdfglb 的标准转换思路

如果你的模型来源不是现成的 .glb / .gltf,而是 Gazebo 使用的 .world.sdf,要先建立一个正确认知:

  1. 先打开 worldmodel.sdf,确认场景中到底引用了哪些模型。
  2. 查找 <include><model><visual><geometry><mesh><uri> 这些关键节点。
  3. 找到真实的可视化资源文件,常见为 .dae.obj.stl 等 mesh 文件。
  4. 对 mesh 文件做格式转换,整理材质、贴图、缩放和坐标轴后,导出为 .glb
  5. 记录 sdf / world 中的 posescale、模型名称,供前端重新摆放场景使用。
  6. 前端用 GLTFLoader 加载这些 .glb,再根据记录好的位姿信息恢复场景布局。

你可以把这个流程概括为一句话:

不是直接把 world / sdf 整体变成 glb,而是“从 world / sdf 中提取视觉模型,再转换为 glb,最后在前端重新组装场景”。

5) 转换时要优先识别的三类资源

在 Gazebo 场景里,最常见的是下面三类可视化来源:

6) 推荐的人工转换工具与操作顺序

推荐操作顺序:

world / sdf
→ 找到 visual 对应的 mesh
→ 导入 Blender
→ 检查材质 / 贴图 / 缩放 / 朝向
→ 导出为 glb
→ 前端通过 GLTFLoader 加载

解释:

7) 转换后的常见风险提醒

对前端来说,world / sdf -> glb 的本质不是“仿真文件格式转换”,而是“把 Gazebo 中的可视资产整理成浏览器可加载的模型资源”。

8) 从 xacro / urdfglb:为“关节联动”准备的资产规则(扩展认知)

如果你的机器人来自 xacro / urdf(Gazebo 和 ROS2 中最常见),要先建立一个对“联动控制”非常关键的认知:

  1. xacro -> urdf:先把宏展开成可读的 urdf(方便检查 link/joint/visual)。
  2. urdf -> 找 visual mesh:在每个 link 的 <visual> 中找到真正的 mesh 资源(常见 .dae/.stl/.obj)。
  3. mesh -> glb:用工具把 mesh 转为 .glb,并检查贴图、缩放、朝向。
  4. “联动预埋”检查:确保导出的 .glb 保留了合理的层级与节点命名,便于前端建立映射并驱动局部旋转。

为什么这里要强调“层级与命名”:

把下面 5 条当成“联动能否顺利”的硬指标:

  1. 层级存在:机器人至少是“多节点对象树”,而不是一个无法拆分的单 mesh。
  2. 命名稳定:关键节点(link 或关节对应的旋转组)有稳定可读的 name,并尽量与 URDF 的 link/joint 命名保持一致。
  3. 枢轴正确:关节旋转的支点(pivot)在合理位置(通常接近关节轴位置),否则前端旋转会出现“绕空气转圈”。
  4. 轴向明确:关节旋转轴方向一致(例如绕 Z 轴或绕某一固定轴),否则即使角度对了,视觉也会乱。
  5. 可验证:前端加载后能通过“打印节点树 + 手动旋转一个节点”的方式验证联动是否可行。

这一讲不要求你把“URDF joint axis”严格还原到前端(那属于后续 ROS2 联动课程),但至少要做到:你能在前端选中一个节点并让它旋转,旋转效果看起来“像在转关节”。

GLTFLoader 的定位很简单:

最小导入方式:

import * as THREE from 'three'; // Three.js 核心包(Scene/Camera/Renderer 等都从这里来)
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; // glTF 家族加载器(注意 .js 后缀)

解释:

工程结论:

工业项目中,模型加载不是“能出来就行”,而是要做到“部分失败可见、整体状态可知、问题可排查”。

如果你要一次加载多个模型,Promise 风格会更适合组织代码:

const loader = new GLTFLoader();
// loadAsync:Promise 风格加载(适合 async/await 写法)
const gltf = await loader.loadAsync(`${import.meta.env.BASE_URL}models/robot-arm.glb`);
// gltf.scene:模型根节点(Three.js 对象树),拿到后就可以 add 到场景
scene.add(gltf.scene);

解释:

扩展:为“关节联动”预埋(不接 ROS2,先用滑块模拟)

如果你的最终目标是“机器人关节与前端模型联动”,建议从这一讲就把下面三件事做成习惯:看层级、找节点、建映射。这三步决定了你后续能不能把 joint_states 的角度稳定映射到模型上。

  1. 打印模型节点树(找得到节点,才谈得上控制)
function dumpObjectTree(root: THREE.Object3D) {
  // 用数组收集输出,避免 traverse 时频繁 console.log 造成输出难看/乱序
  const lines: string[] = [];
  root.traverse((obj) => {
    const type = obj.type || 'Object3D';
    const name = obj.name || '(no-name)';
    lines.push(`${type} | ${name}`);
  });
  console.log(lines.join('\n'));
}

解释:

  1. 建立 name -> Object3D 索引(后续把 joint 名映射到对象)
function buildNodeIndex(root: THREE.Object3D) {
  // name -> Object3D 的索引表:后续 joint_states 进来时可 O(1) 找到要驱动的节点
  const index = new Map<string, THREE.Object3D>();
  root.traverse((obj) => {
    if (!obj.name) return;
    index.set(obj.name, obj);
  });
  return index;
}

解释:

  1. 用滑块模拟一个关节旋转(先验证“能转”,再谈“数据驱动”)
function setJointAngle(node: THREE.Object3D, angleRad: number) {
  // 最小演示:先固定绕 Z 轴转(真实项目的旋转轴应来自 URDF joint axis / tf)
  node.rotation.z = angleRad;
}

function degToRad(deg: number) {
  // Three.js 的 rotation 使用弧度(rad),课堂滑块更常用角度(deg),所以需要换算
  return (deg * Math.PI) / 180;
}

解释:

可复制运行版(推荐):src/components/GltfJointPrepLab.vue

<template>
  <div class="page">
    <aside class="panel">
      <h3>关节联动预埋(滑块模拟)</h3>
      <p class="hint">
        本组件不接 ROS2,只验证“找得到节点并能驱动局部旋转”。后续课程把滑块输入替换成 joint_states 即可。
      </p>

      <div class="row">
        <label>目标节点名</label>
        <input v-model="targetNodeName" class="input" placeholder="例如:joint_1 或 link_1" />
      </div>

      <div class="row">
        <label>角度(度)</label>
        <input v-model.number="angleDeg" type="range" min="0" max="180" step="1" />
        <div class="value">{{ angleDeg }}°</div>
      </div>

      <div class="actions">
        <button type="button" class="btn" @click="dumpTree" :disabled="!modelRoot">打印节点树</button>
        <button type="button" class="btn" @click="applyAngle" :disabled="!modelRoot">应用角度</button>
        <button type="button" class="btn" @click="reload" :disabled="loading">重新加载</button>
      </div>

      <p>状态:{{ loading ? '加载中' : modelRoot ? '已加载' : '未加载' }}</p>
      <p v-if="notice" class="notice">{{ notice }}</p>
      <p v-if="errorMessage" class="error">{{ errorMessage }}</p>
    </aside>

    <div ref="containerRef" class="three-container"></div>
  </div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

const containerRef = ref<HTMLDivElement | null>(null);
// 目标节点名:来自 glb 内部 Object3D.name(建议先“打印节点树”再复制)
const targetNodeName = ref('');
// 滑块角度(deg):更符合课堂直觉;写进 rotation 前会换算成 rad
const angleDeg = ref(0);
// notice:用于“正常提示”(例如操作成功、下一步建议)
const notice = ref('');
// errorMessage:用于“错误提示”(例如找不到节点名)
const errorMessage = ref('');
// loading:控制按钮禁用/状态显示,避免重复触发加载
const loading = ref(false);

// Three.js 核心对象:与 Vue 响应式解耦,避免频繁 re-render
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
let rafId: number | null = null;
let resizeObserver: ResizeObserver | null = null;

let modelRoot: THREE.Object3D | null = null;
// 节点索引:name -> Object3D,用于把“关节名/节点名”快速映射到可控制对象
let nodeIndex: Map<string, THREE.Object3D> = new Map();

function degToRad(deg: number) {
  // Three.js rotation 用弧度
  return (deg * Math.PI) / 180;
}

function resize() {
  const container = containerRef.value;
  if (!container || !renderer || !camera) return;
  const width = container.clientWidth;
  const height = container.clientHeight;
  if (width <= 0 || height <= 0) return;
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setSize(width, height, false);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
}

function animate() {
  if (!renderer || !scene || !camera) return;
  renderer.render(scene, camera);
  rafId = requestAnimationFrame(animate);
}

function createBaseScene(container: HTMLDivElement) {
  // 与前两个实验保持一致的场景骨架:方便课堂对照与复用排错经验
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x0b1220);

  camera = new THREE.PerspectiveCamera(60, 1, 0.1, 150);
  camera.position.set(4, 3, 6);
  camera.lookAt(0, 1, 0);

  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.shadowMap.enabled = true;
  container.appendChild(renderer.domElement);

  scene.add(new THREE.AxesHelper(2));
  scene.add(new THREE.GridHelper(10, 10));

  const ambient = new THREE.AmbientLight(0xffffff, 0.8);
  const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
  dirLight.position.set(6, 8, 4);
  dirLight.castShadow = true;
  scene.add(ambient, dirLight);

  const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(12, 12),
    new THREE.MeshLambertMaterial({ color: 0x1f2937 }),
  );
  floor.rotation.x = -Math.PI / 2;
  floor.receiveShadow = true;
  scene.add(floor);
}

function buildNodeIndex(root: THREE.Object3D) {
  // 把“遍历整棵树找节点”提前做成索引:后续每次更新角度都能 O(1) 查找
  const index = new Map<string, THREE.Object3D>();
  root.traverse((obj) => {
    if (!obj.name) return;
    index.set(obj.name, obj);
  });
  return index;
}

function dumpObjectTree(root: THREE.Object3D) {
  // 输出 type + name:用来判断导出时是否保留了层级/命名(联动能否做起来的关键)
  const lines: string[] = [];
  root.traverse((obj) => {
    const type = obj.type || 'Object3D';
    const name = obj.name || '(no-name)';
    lines.push(`${type} | ${name}`);
  });
  console.log(lines.join('\n'));
}

function applyAngle() {
  if (!modelRoot) return;
  errorMessage.value = '';
  notice.value = '';

  if (!targetNodeName.value) {
    // 没有目标名时不给 silent fail:直接在面板提示下一步操作
    notice.value = '请先填写目标节点名(建议先点“打印节点树”)。';
    return;
  }

  // 通过索引表按 name 取节点:这一步就是“未来 joint_states 映射”的雏形
  const node = nodeIndex.get(targetNodeName.value);
  if (!node) {
    errorMessage.value = `找不到节点名:${targetNodeName.value}(检查导出时是否保留 name,或节点是否被改名/打平)`;
    return;
  }

  // 最小验证:固定绕 Z 轴旋转(真实项目旋转轴应来自 URDF joint axis / tf)
  node.rotation.z = degToRad(angleDeg.value);
  notice.value = `已对 ${targetNodeName.value} 应用角度 ${angleDeg.value}°(绕 Z 轴)`;
}

function clearModel() {
  if (!scene || !modelRoot) return;
  // 从场景树上移除模型(“看不见”)
  scene.remove(modelRoot);
  // 课堂版:这里只移除引用与索引;严格释放可参考前两个实验的 disposeSceneMeshes/Set 去重
  modelRoot = null;
  nodeIndex = new Map();
}

async function loadModel() {
  if (!scene) return;
  // 重新加载前先清掉旧模型:避免叠加多个 root
  clearModel();

  loading.value = true;
  errorMessage.value = '';
  notice.value = '';

  try {
    const loader = new GLTFLoader();
    const modelUrl = `${import.meta.env.BASE_URL}models/robot-arm.glb`;
    // loadAsync:用 async/await 组织,错误通过 try/catch 进入 UI
    const gltf = await loader.loadAsync(modelUrl);
    modelRoot = gltf.scene;

    modelRoot.position.set(0, 0, 0);
    modelRoot.scale.setScalar(1.2);
    modelRoot.traverse((obj) => {
      const mesh = obj as THREE.Mesh;
      if (!mesh.isMesh) return;
      mesh.castShadow = true;
      mesh.receiveShadow = true;
    });

    // 建立 name -> Object3D 索引(关键步骤):没有稳定 name,后续 joint 映射会非常痛苦
    nodeIndex = buildNodeIndex(modelRoot);
    scene.add(modelRoot);

    notice.value = '模型加载完成。建议点击“打印节点树”,再选择一个有 name 的节点做旋转验证。';
  } catch (error) {
    errorMessage.value = error instanceof Error ? error.message : '模型加载失败,请检查 public/models/robot-arm.glb 是否存在';
  } finally {
    loading.value = false;
  }
}

function dumpTree() {
  if (!modelRoot) return;
  dumpObjectTree(modelRoot);
  notice.value = '已在控制台输出节点树(type | name)。';
}

function reload() {
  void loadModel();
}

watch([angleDeg], () => {
  // 滑块变化就立即应用角度:模拟“实时数据驱动”(后续把 angleDeg 替换成 ROS2 joint_states 即可)
  applyAngle();
});

onMounted(() => {
  const container = containerRef.value;
  if (!container) throw new Error('Three container not found');

  createBaseScene(container);
  resize();
  resizeObserver = new ResizeObserver(() => resize());
  resizeObserver.observe(container);

  void loadModel();
  animate();
});

onBeforeUnmount(() => {
  if (rafId !== null) cancelAnimationFrame(rafId);
  if (resizeObserver && containerRef.value) resizeObserver.unobserve(containerRef.value);

  clearModel();

  renderer?.dispose();
  if (renderer?.domElement.parentNode) {
    renderer.domElement.parentNode.removeChild(renderer.domElement);
  }

  resizeObserver = null;
  rafId = null;
  camera = null;
  scene = null;
  renderer = null;
});
</script>

<style scoped>
.page {
  display: grid;
  grid-template-columns: 340px 1fr;
  gap: 12px;
}

.panel {
  background: #0f172a;
  color: #e5e7eb;
  border: 1px solid #1f2937;
  border-radius: 8px;
  padding: 12px;
}

.three-container {
  width: 100%;
  min-height: 560px;
  border: 1px solid #1f2937;
  border-radius: 8px;
  overflow: hidden;
}

.hint {
  color: #94a3b8;
}

.row {
  display: grid;
  gap: 6px;
  margin-top: 10px;
}

.input {
  border: 1px solid #1f2937;
  border-radius: 8px;
  padding: 8px;
  background: #0b1220;
  color: #e5e7eb;
}

.actions {
  display: grid;
  gap: 8px;
  margin-top: 12px;
}

.btn {
  border: 1px solid #1f2937;
  border-radius: 10px;
  padding: 10px 12px;
  background: #111827;
  color: #e5e7eb;
  cursor: pointer;
}

.btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.value {
  color: #cbd5e1;
}

.notice {
  color: #93c5fd;
}

.error {
  color: #fca5a5;
}
</style>

解释(保证它“能跑起来”):

当你遇到“加载失败”或“加载成功但看不见”,按下面顺序查,效率最高:

  1. 先查路径
  1. 再查看浏览器网络请求
  1. 再查模型是否加入场景
  1. 再查模型的可见性
  1. 最后查光照与材质

路径错,通常会触发 onError;位置和缩放错,通常会“加载成功但看不见”;这两类问题不要混在一起查。

本讲只讲“最基础、最容易落地”的优化,不追求高级压缩插件:

  1. 控制模型体积
  1. 像素比封顶
  1. 按需加载
  1. 重复模型优先复用
  1. 离开页面及时释放资源

1) 加载成功率测试

2) 加载时间测试

3) 失败提示测试

4) 场景适配测试

5) 卸载释放测试

七、项目工坊(整合版:工业模型加入现有 3D 场景)

本次项目工坊与学生任务统一口径:

建议完成顺序:

  1. 先用一个 .glb 跑通最小加载流程
  2. 再增加第二个模型,验证场景摆放是否合理
  3. 再补“进度 / 成功 / 失败”状态
  4. 最后再做多模型结果统计与简单测试
  5. 可选:做一次“关节联动预埋”验证(打印节点树 → 找到一个节点 → 用滑块让它按角度旋转)

九、大模型任务(可直接复制使用)

任务 1:AI 生成 GLB / GLTF 模型加载完整代码

请为 Vue3 + Vite + TypeScript + Three.js 生成一个可运行的 GLB / GLTF 模型加载组件,要求:
1) 使用 GLTFLoader 从 public/models/ 目录加载模型;
2) 至少包含一个 .glb 示例路径;
3) 页面上显示加载状态、加载进度、加载成功提示和加载失败提示;
4) 模型加载后要添加到场景,并能设置 position 与 scale;
5) 包含 onBeforeUnmount 的资源释放;
6) 输出完整的 .vue 文件代码,并逐段解释关键逻辑。

期望输出校验点:

任务 2:推荐适合工业分拣产线的 3D 模型资源

请推荐适合工业分拣产线可视化教学的 3D 模型资源方向,要求覆盖:
1) 工业机器人 / 机械臂;
2) 分拣传送带;
3) 托盘、分拣箱、工作台等基础设备;
4) 优先推荐适合导出为 GLB / GLTF 的资源类型。
请用表格输出“模型类别、建议用途、课堂难度、资源选择建议”。

期望输出校验点:

任务 3:讲解模型加载失败的常见原因及排查方法

我正在用 Three.js 的 GLTFLoader 加载工业模型,请按“最短排查路径”列出模型加载失败或加载成功但看不见的常见原因,要求覆盖:
1) 路径错误;
2) gltf 关联资源缺失;
3) 位置或缩放不合理;
4) 相机视野问题;
5) 光照或材质导致看不清;
6) 资源释放不当导致重复进入页面越来越卡。
请输出为 checklist,并给出每一项的验证方法。

期望输出校验点:

任务 4(扩展):AI 生成“关节联动预埋”最小示例代码(不接 ROS2)

我想为后续“机器人关节与前端模型联动”做准备,请为 Vue3 + Vite + TypeScript + Three.js 生成一个最小可运行示例,要求:
1) 用 GLTFLoader 加载一个 robot-arm.glb;
2) 加载完成后打印 gltf.scene 的节点树(输出 type + name);
3) 建立 name -> Object3D 的索引 Map;
4) 页面提供一个滑块(0~180 度),滑块变化时驱动某个指定 name 的节点绕 Z 轴旋转;
5) 如果找不到该 name,要在页面上给出明确提示(而不是静默失败)。
请输出完整的 .vue 文件代码,并解释每个关键步骤的目的。

期望输出校验点:


课后作业(布置)

  1. 完成至少 2 个工业 3D 模型(如机械臂、分拣机)的加载,实现加载进度监听与失败处理。

提交要求:

  1. 截图模型加载完成后的场景效果,标注加载流程关键代码。

提交要求:

  1. 撰写 150 字左右说明,简述 GLB / GLTF 模型的优势及加载过程中需要注意的问题。

评分要点(参考):

参考与延伸

自检清单(发布前已检查)